iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0

前言

終於來到倒數第二篇了,也可以說是名義上的最後一篇,前面講了很多圍繞在用戶註冊登入的相關知識,那今天就延續,寫寫用戶圖片上傳的檔案處理知識。

Multer 核心概念

什麼是 Multer?

Multer 是一個 Node.js 中介軟體,專門用於處理 multipart/form-data 格式的表單數據,主要用於檔案上傳。它基於 busboy 構建,提供了簡潔的 API 來處理檔案。

Multer 的工作流程

  1. 接收來自客戶端的 multipart 請求
  2. 解析表單數據和檔案
  3. 根據設定進行檔案過濾和驗證
  4. 將檔案儲存到指定位置
  5. req.filereq.files 中提供檔案資訊

設定 Multer 上傳中介層

middlewares/upload.ts 建立上傳設定:

import multer from 'multer';
import path from 'path';
import fs from 'fs';

// 確保 uploads 資料夾存在
const uploadDir = path.join(__dirname, '../../uploads');
if (!fs.existsSync(uploadDir)) {
  fs.mkdirSync(uploadDir, { recursive: true });
}

// 設定儲存方式
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, uploadDir);
  },
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname); // 取副檔名
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
    cb(null, `${file.fieldname}-${uniqueSuffix}${ext}`);
  }
});

// 檔案過濾與大小限制
const upload = multer({
  storage,
  limits: {
    fileSize: 2 * 1024 * 1024 // 限制 2MB
  },
  fileFilter: (req, file, cb) => {
    const allowed = ['image/jpeg', 'image/png', 'image/jpg'];
    if (!allowed.includes(file.mimetype)) {
      return cb(new Error('僅允許上傳 JPEG 或 PNG 圖片'));
    }
    cb(null, true);
  }
});

export const avatarUpload = upload.single('avatar');

深入理解 Multer 設定

1. 儲存引擎 (Storage Engine)

Multer 提供兩種儲存方式:

DiskStorage - 檔案系統儲存

const diskStorage = multer.diskStorage({
  destination: (req, file, cb) => {
    // 可以根據不同條件動態決定儲存位置
    const userFolder = path.join(uploadDir, req.user?.id || 'anonymous');
    if (!fs.existsSync(userFolder)) {
      fs.mkdirSync(userFolder, { recursive: true });
    }
    cb(null, userFolder);
  },
  filename: (req, file, cb) => {
    // 生成安全的檔案名稱
    const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
    const uniqueName = `${Date.now()}-${sanitizedName}`;
    cb(null, uniqueName);
  }
});

優點:

  • 檔案直接儲存在磁碟,節省記憶體
  • 適合大檔案
  • 可以直接透過靜態路由訪問

缺點:

  • 需要管理檔案系統
  • 水平擴展時需要共享儲存

MemoryStorage - 記憶體儲存

const memoryStorage = multer.memoryStorage();

const uploadToMemory = multer({
  storage: memoryStorage,
  limits: { fileSize: 1024 * 1024 } // 限制 1MB
});

// 在 controller 中處理
const handleUpload = async (req: Request, res: Response) => {
  if (!req.file) {
    return res.status(400).json({ error: '未上傳檔案' });
  }
  
  // req.file.buffer 包含檔案內容
  const fileBuffer = req.file.buffer;
  
  // 可以直接上傳到雲端服務 (S3, GCS 等)
  await uploadToCloud(fileBuffer);
  
  res.json({ message: '上傳成功' });
};

優點:

  • 可以直接處理檔案內容
  • 適合需要立即處理或轉發的場景
  • 適合雲端儲存整合

缺點:

  • 大檔案會佔用大量記憶體
  • 不適合高並發場景

2. 檔案過濾器 (File Filter)

更完善的檔案驗證:

// 更嚴格的 MIME 類型檢查
const strictFileFilter = (req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
  // 1. 檢查 MIME 類型
  const allowedMimes = ['image/jpeg', 'image/png', 'image/jpg', 'image/webp'];
  
  // 2. 檢查副檔名
  const ext = path.extname(file.originalname).toLowerCase();
  const allowedExts = ['.jpg', '.jpeg', '.png', '.webp'];
  
  if (!allowedMimes.includes(file.mimetype)) {
    return cb(new Error(`不支援的檔案類型: ${file.mimetype}`));
  }
  
  if (!allowedExts.includes(ext)) {
    return cb(new Error(`不支援的副檔名: ${ext}`));
  }
  
  cb(null, true);
};

// 根據檔案欄位名稱做不同驗證
const dynamicFileFilter = (req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
  if (file.fieldname === 'avatar') {
    const allowedMimes = ['image/jpeg', 'image/png'];
    if (!allowedMimes.includes(file.mimetype)) {
      return cb(new Error('大頭照只接受 JPEG 或 PNG 格式'));
    }
  } else if (file.fieldname === 'document') {
    const allowedMimes = ['application/pdf'];
    if (!allowedMimes.includes(file.mimetype)) {
      return cb(new Error('文件只接受 PDF 格式'));
    }
  }
  
  cb(null, true);
};

3. 檔案大小限制 (Limits)

Multer 提供多種限制選項:

const upload = multer({
  storage: storage,
  limits: {
    fileSize: 5 * 1024 * 1024,      // 檔案大小限制 (5MB)
    files: 10,                       // 最多上傳檔案數
    fields: 20,                      // 最多表單欄位數
    fieldSize: 100 * 1024,          // 欄位值大小限制 (100KB)
    fieldNameSize: 100,             // 欄位名稱長度限制
    headerPairs: 2000               // 最多 header 數量
  }
});

不同的上傳方式

單檔上傳

// single(fieldname) - 上傳單一檔案
export const singleUpload = upload.single('avatar');

// 在 route 中使用
router.post('/upload/avatar', singleUpload, async (req: Request, res: Response) => {
  if (!req.file) {
    return res.status(400).json({ error: '未選擇檔案' });
  }
  
  // req.file 包含檔案資訊
  const fileInfo = {
    filename: req.file.filename,
    originalname: req.file.originalname,
    mimetype: req.file.mimetype,
    size: req.file.size,
    path: req.file.path
  };
  
  res.json({ message: '上傳成功', file: fileInfo });
});

多檔上傳(相同欄位名)

// array(fieldname, maxCount) - 上傳多個檔案到同一欄位
export const multipleUpload = upload.array('photos', 5);

router.post('/upload/photos', multipleUpload, async (req: Request, res: Response) => {
  if (!req.files || !Array.isArray(req.files)) {
    return res.status(400).json({ error: '未選擇檔案' });
  }
  
  const filesInfo = req.files.map(file => ({
    filename: file.filename,
    originalname: file.originalname,
    size: file.size
  }));
  
  res.json({ 
    message: `成功上傳 ${req.files.length} 個檔案`, 
    files: filesInfo 
  });
});

多檔上傳(不同欄位名)

// fields(fields) - 上傳多個檔案到不同欄位
export const mixedUpload = upload.fields([
  { name: 'avatar', maxCount: 1 },
  { name: 'gallery', maxCount: 5 },
  { name: 'document', maxCount: 1 }
]);

router.post('/upload/profile', mixedUpload, async (req: Request, res: Response) => {
  const files = req.files as { [fieldname: string]: Express.Multer.File[] };
  
  const avatar = files['avatar']?.[0];
  const gallery = files['gallery'] || [];
  const document = files['document']?.[0];
  
  res.json({
    avatar: avatar?.filename,
    gallery: gallery.map(f => f.filename),
    document: document?.filename
  });
});

任意欄位上傳

// any() - 接受任意欄位的檔案(不建議在生產環境使用)
export const anyUpload = upload.any();

router.post('/upload/any', anyUpload, async (req: Request, res: Response) => {
  const files = req.files as Express.Multer.File[];
  res.json({ 
    message: `收到 ${files.length} 個檔案`,
    files: files.map(f => ({ field: f.fieldname, name: f.filename }))
  });
});

錯誤處理

全域錯誤處理中介層

// middlewares/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import multer from 'multer';

export const uploadErrorHandler = (
  err: any,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  if (err instanceof multer.MulterError) {
    // Multer 特定錯誤
    switch (err.code) {
      case 'LIMIT_FILE_SIZE':
        return res.status(400).json({
          error: '檔案大小超過限制',
          message: '請上傳小於 2MB 的檔案'
        });
      case 'LIMIT_FILE_COUNT':
        return res.status(400).json({
          error: '檔案數量超過限制',
          message: `最多只能上傳 ${err.field} 個檔案`
        });
      case 'LIMIT_UNEXPECTED_FILE':
        return res.status(400).json({
          error: '未預期的檔案欄位',
          message: `欄位 ${err.field} 不被允許`
        });
      default:
        return res.status(400).json({
          error: 'Multer 錯誤',
          message: err.message
        });
    }
  }
  
  if (err.message) {
    // 自訂錯誤(來自 fileFilter)
    return res.status(400).json({
      error: '檔案驗證失敗',
      message: err.message
    });
  }
  
  next(err);
};

在路由中使用錯誤處理

// routes/upload.routes.ts
import express from 'express';
import { avatarUpload } from '../middlewares/upload';
import { uploadErrorHandler } from '../middlewares/errorHandler';

const router = express.Router();

router.post('/upload/avatar', 
  avatarUpload, 
  uploadErrorHandler,  // 錯誤處理中介層
  async (req, res) => {
    // 上傳成功的處理邏輯
    res.json({ message: '上傳成功', file: req.file });
  }
);

export default router;

上一篇
Day28 - TypeORM
下一篇
Day30 - 結語
系列文
欸欸!! 這是我的學習筆記30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言